Skip to content

Fix func_vehicle and func_tracktrain engine sound bleeding into adjacent precached sounds - #1185

Merged
s1lentq merged 1 commit into
rehlds:masterfrom
Nord1cWarr1or:fix/vehicle-pitch-bleed
Aug 27, 2026
Merged

Fix func_vehicle and func_tracktrain engine sound bleeding into adjacent precached sounds#1185
s1lentq merged 1 commit into
rehlds:masterfrom
Nord1cWarr1or:fix/vehicle-pitch-bleed

Conversation

@Nord1cWarr1or

@Nord1cWarr1or Nord1cWarr1or commented Aug 23, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the func_vehicle / func_tracktrain engine sound bleeding into adjacent precache entries (e.g. hostage voice lines playing instead of the vehicle engine at certain speeds).

Fixes #1184

The Bug

When driving a func_vehicle at certain speeds, the engine sound is replaced by whatever sound is next in the client's sound cache. On maps with hostages players hear "let's get out of here" (hostage/huse/getouttahere.wav) instead of the vehicle engine. Which sound bleeds in depends on the map's precache order — it is always the entry loaded right after the vehicle sound.

snd_show confirms the channel is still playing the correct sound (plats/vehicle4.wav), yet a different sound is heard.

Root Cause

The defect itself is in the GoldSrc client engine (hw.dll), not in the game DLL. GoldSrc mixes a sound channel through one of two paths:

Path Used when Bounds its reads
pitch-shifting (VOX) channel pitch != PITCH_NORM yes, by the length of the wave
plain (SND_PaintChannelFrom8) channel pitch == PITCH_NORM no

A sound started with a pitch other than 100 is wrapped into a single-word VOX "sentence" (VOX_MakeSingleWordSentence) — that is what makes pitch shifting possible at all. The plain path has no bounds check whatsoever: it copies count samples from the channel's playback cursor and trusts the channel's end-of-wave marker completely.

That marker is where the two paths disagree. It is maintained in mixing-clock units, but the pitch-shifting path corrects it in source-sample units. For 22 kHz sounds those units differ by 2x, because the mixer runs at 11 kHz and mixes 22 kHz sounds in a separate double-rate pass (hisound 1, the default).

So while the engine sound plays with a shifted pitch, the end-of-wave marker silently drifts ahead of the real sample data — by 2 * (1 - pitch / 100) samples per mixed sample, roughly 8800 samples per second at pitch 60. Nothing is audible yet: the pitch-shifting path will not read past the end of the wave no matter what the marker says. As soon as the reported pitch becomes exactly 100, the plain path takes over, follows the inflated marker past the end of the wave and mixes in whatever the sound cache holds next — up to about a second of foreign audio, which is why a whole recognisable voice line is heard instead of a click.

hisound 0 makes the bug disappear, which matches this explanation: the sound is then downsampled to the mixer rate, the double-rate pass is not used, and the unit mismatch is gone.

Existing clients cannot be fixed, so the game DLL has to avoid the condition that triggers it.

The Fix

1. Never report a pitch of exactly PITCH_NORM

int ipitch = int(flpitch);
if (ipitch == PITCH_NORM)
    ipitch = PITCH_NORM - 1;

This is what actually fixes the bleed: the channel stays on the pitch-shifting path, which is bounded by the length of the wave, and the unbounded plain path is never reached. A 1% pitch offset is inaudible.

This is the same guard Valve already applies to looping sounds whose pitch is modulated:

  • CFuncRotating::RampPitchVol()pitch = PITCH_NORM - 1 (bmodels.cpp)
  • CAmbientGenericpitch = PITCH_NORM + 1, commented // don't send 'no pitch' ! (sound.cpp)

func_vehicle and func_tracktrain were the two entities missing it.

2. Send pitch/volume updates through EMIT_SOUND_DYN instead of the client event

// was (lossy event encoding):
PLAYBACK_EVENT_FULL(FEV_UPDATE, edict(), m_usAdjustPitch, ...);

// now (engine sound API, 8-bit pitch):
EMIT_SOUND_DYN(ENT(pev), CHAN_STATIC, (char *)STRING(pev->noise),
    m_flVolume, ATTN_NORM, SND_CHANGE_PITCH | SND_CHANGE_VOL, ipitch);

The event packs the pitch as pitch / 10 into 6 bits, so the client only ever sees multiples of 10 and every value in [100, 110) arrives as exactly PITCH_NORM. With that encoding the nearest usable value to 100 is 110 — a full 10% step. EMIT_SOUND_DYN puts the pitch on the wire as a byte (SV_BuildSoundMsg writes 8 bits), so PITCH_NORM - 1 reaches the client unchanged; as a side effect the engine pitch also stops being quantized to steps of 10 across the whole range and the volume is no longer squeezed into 6 bits.

This is the same call CFuncRotating already uses.

It also fixes two long-standing defects of the event path:

  • The stop was PVS-filtered. The sound is started with EMIT_SOUND_DYN on CHAN_STATIC, which the engine delivers as a reliable broadcast to everyone, while the stop event only reaches players nearby. Anyone who was elsewhere when the vehicle stopped kept hearing the engine loop forever. SND_STOP sent through EMIT_SOUND_DYN is broadcast reliably — which is exactly why the engine special-cases SND_STOP and CHAN_STATIC.
  • Late joiners heard the engine at the map origin. A player connecting after the vehicle started moving never receives the initial sound message; the pitch update event then falls through to starting the sound from scratch, using the event's origin — which is g_vecZero. EMIT_SOUND_DYN uses the entity's real position.

StopSound() is updated the same way (EMIT_SOUND_DYN + SND_STOP).

Everything is under REGAMEDLL_FIXES; the original PLAYBACK_EVENT_FULL path is kept in the #else branch. PRECACHE_EVENT("events/vehicle.sc") is deliberately left in place so event indices do not shift for clients.

3. Same treatment for func_tracktrain

CFuncTrackTrain::UpdateSound() / StopSound() contain the same code and the same bug. The train also had no upper pitch clamp at all, so one is added under REGAMEDLL_FIXES (TRAIN_MAXPITCH) — without it a fast enough train would push the pitch past the byte range the sound message carries.

Why both parts

Part 1 is the actual fix. Part 2 is what makes part 1 usable: through the event encoding the closest reachable value to 100 is 110, a 10% jump; through EMIT_SOUND_DYN it is 99, a 1% offset nobody can hear. Part 2 additionally fixes the two delivery problems listed above.

Behaviour changes

  • Pitch updates for func_vehicle / func_tracktrain are no longer sent as events/vehicle.sc / events/train.sc. Plugins hooking FM_PlaybackEvent for these will no longer see them — they now show up as FM_EmitSound instead.
  • Updates are delivered as svc_sound on CHAN_STATIC, which the engine broadcasts reliably to all players rather than only to those in PAS. That is roughly 15 bytes per second per moving vehicle per client. In exchange, stop messages can no longer be missed.
  • The engine pitch is no longer quantized to steps of 10, so the sound ramps smoothly with speed.

Testing

Tested on awesome_cars. The getouttahere.wav bleed is gone. Verified on a vanilla client as well: with only the server-side change applied and an unpatched client, the bleed no longer occurs. Some crackling remains at speed transitions — that is a separate sound quality issue, not the precache bleed.

References

  • Engine analysis of the GoldSrc sound mixer (two mixing paths, hi-res double-rate pass, end-of-wave bookkeeping)
  • Same PITCH_NORM avoidance already present in bmodels.cpp (CFuncRotating) and sound.cpp (CAmbientGeneric)
  • Server sound path: SV_StartSound / SV_BuildSoundMsg in ReHLDS

@Nord1cWarr1or Nord1cWarr1or changed the title Clamp vehicle engine pitch to avoid PITCH_NORM sound bleed Fix func_vehicle sound bleeding into adjacent precache entries Aug 24, 2026
@Nord1cWarr1or Nord1cWarr1or changed the title Fix func_vehicle sound bleeding into adjacent precache entries Fix func_vehicle and func_tracktrain engine sound bleeding into adjacent precached sounds Aug 24, 2026
@Nord1cWarr1or
Nord1cWarr1or force-pushed the fix/vehicle-pitch-bleed branch 2 times, most recently from 7037987 to 3c0975f Compare August 24, 2026 19:20
@Nord1cWarr1or
Nord1cWarr1or force-pushed the fix/vehicle-pitch-bleed branch from 3c0975f to 1c84cf7 Compare August 24, 2026 19:51
@s1lentq
s1lentq merged commit 1c30450 into rehlds:master Aug 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vehicle engine sound bleeds into adjacent precache sounds at PITCH_NORM

2 participants